// Copyright 2016 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

InspectorTest = {};
InspectorTest._dumpInspectorProtocolMessages = false;
InspectorTest._commandsForLogging = new Set();
InspectorTest._sessions = new Set();

InspectorTest.log = utils.print.bind(utils);
InspectorTest.quitImmediately = utils.quit.bind(utils);

InspectorTest.logProtocolCommandCalls = function(command) {
  InspectorTest._commandsForLogging.add(command);
}

InspectorTest.completeTest = function() {
  var promises = [];
  for (var session of InspectorTest._sessions)
    promises.push(session.Protocol.Debugger.disable());
  Promise.all(promises).then(() => utils.quit());
}

InspectorTest.waitForPendingTasks = function() {
  var promises = [];
  for (var session of InspectorTest._sessions)
    promises.push(session.Protocol.Runtime.evaluate({ expression: "new Promise(r => setTimeout(r, 0))//# sourceURL=wait-for-pending-tasks.js", awaitPromise: true }));
  return Promise.all(promises);
}

InspectorTest.startDumpingProtocolMessages = function() {
  InspectorTest._dumpInspectorProtocolMessages = true;
}

InspectorTest.logMessage = function(originalMessage) {
  const nonStableFields = new Set([
    'objectId', 'scriptId', 'exceptionId', 'timestamp', 'executionContextId',
    'callFrameId', 'breakpointId', 'bindRemoteObjectFunctionId',
    'formatterObjectId', 'debuggerId', 'bodyGetterId', 'uniqueId',
    'executionContextUniqueId'
  ]);
  const message = JSON.parse(JSON.stringify(originalMessage, replacer.bind(null, Symbol(), nonStableFields)));
  if (message.id)
    message.id = '<messageId>';

  InspectorTest.logObject(message);
  return originalMessage;

  function replacer(stableIdSymbol, nonStableFields, name, val) {
    if (nonStableFields.has(name))
      return `<${name}>`;
    if (name === 'internalProperties') {
      const stableId = val.find(prop => prop.name === '[[StableObjectId]]');
      if (stableId)
        stableId.value[stableIdSymbol] = true;
    }
    if (name === 'parentId')
      return { id: '<id>' };
    if (val && val[stableIdSymbol])
      return '<StablectObjectId>';
    return val;
  }
}

InspectorTest.logObject = function(object, title) {
  var lines = [];

  function dumpValue(value, prefix, prefixWithName) {
    if (typeof value === "object" && value !== null) {
      if (value instanceof Array)
        dumpItems(value, prefix, prefixWithName);
      else
        dumpProperties(value, prefix, prefixWithName);
    } else {
      lines.push(prefixWithName + String(value).replace(/\n/g, " "));
    }
  }

  function dumpProperties(object, prefix, firstLinePrefix) {
    prefix = prefix || "";
    firstLinePrefix = firstLinePrefix || prefix;
    lines.push(firstLinePrefix + "{");

    var propertyNames = Object.keys(object);
    propertyNames.sort();
    for (var i = 0; i < propertyNames.length; ++i) {
      var name = propertyNames[i];
      if (!object.hasOwnProperty(name))
        continue;
      var prefixWithName = "    " + prefix + name + " : ";
      dumpValue(object[name], "    " + prefix, prefixWithName);
    }
    lines.push(prefix + "}");
  }

  function dumpItems(object, prefix, firstLinePrefix) {
    prefix = prefix || "";
    firstLinePrefix = firstLinePrefix || prefix;
    lines.push(firstLinePrefix + "[");
    for (var i = 0; i < object.length; ++i)
      dumpValue(object[i], "    " + prefix, "    " + prefix + "[" + i + "] : ");
    lines.push(prefix + "]");
  }

  dumpValue(object, "", title || "");
  InspectorTest.log(lines.join("\n"));
}

InspectorTest.decodeBase64 = function(base64) {
  const LOOKUP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

  const paddingLength = base64.match(/=*$/)[0].length;
  const bytesLength = base64.length * 0.75 - paddingLength;

  let bytes = new Uint8Array(bytesLength);

  for (let i = 0, p = 0; i < base64.length; i += 4, p += 3) {
    let bits = 0;
    for (let j = 0; j < 4; j++) {
      bits <<= 6;
      const c = base64[i + j];
      if (c !== '=') bits |= LOOKUP.indexOf(c);
    }
    for (let j = p + 2; j >= p; j--) {
      if (j < bytesLength) bytes[j] = bits;
      bits >>= 8;
    }
  }

  return bytes;
}

InspectorTest.trimErrorMessage = function(message) {
  if (!message.error || !message.error.data)
    return message;
  message.error.data = message.error.data.replace(/at position \d+/,
                                                  'at <some position>');
  return message;
}

InspectorTest.ContextGroup = class {
  constructor() {
    this.id = utils.createContextGroup();
  }

  waitForDebugger() {
    return new Promise(resolve => {
      utils.waitForDebugger(this.id, resolve);
    });
  }

  createContext(name) {
    utils.createContext(this.id, name || '');
  }

  schedulePauseOnNextStatement(reason, details) {
    utils.schedulePauseOnNextStatement(this.id, reason, details);
  }

  cancelPauseOnNextStatement() {
    utils.cancelPauseOnNextStatement(this.id);
  }

  addScript(string, lineOffset, columnOffset, url) {
    utils.compileAndRunWithOrigin(this.id, string, url || '', lineOffset || 0, columnOffset || 0, false);
  }

  addInlineScript(string, url) {
    const match = (new Error().stack).split('\n')[2].match(/([0-9]+):([0-9]+)/);
    this.addScript(
        string, match[1] * 1, match[1] * 1 + '.addInlineScript('.length, url);
  }

  addModule(string, url, lineOffset, columnOffset) {
    utils.compileAndRunWithOrigin(this.id, string, url, lineOffset || 0, columnOffset || 0, true);
  }

  loadScript(fileName) {
    this.addScript(utils.read(fileName));
  }

  connect(isFullyTrusted = true) {
    return new InspectorTest.Session(this, Boolean(isFullyTrusted));
  }

  reset() {
    utils.resetContextGroup(this.id);
  }

  setupInjectedScriptEnvironment(session) {
    let scriptSource = '';
    let getters = ["length","internalConstructorName","subtype","getProperty",
        "objectHasOwnProperty","nullifyPrototype","primitiveTypes",
        "closureTypes","prototype","all","RemoteObject","bind",
        "PropertyDescriptor","object","get","set","value","configurable",
        "enumerable","symbol","getPrototypeOf","nativeAccessorDescriptor",
        "isBuiltin","hasGetter","hasSetter","getOwnPropertyDescriptor",
        "description","isOwn","name",
        "typedArrayProperties","keys","getOwnPropertyNames",
        "getOwnPropertySymbols","isPrimitiveValue","com","toLowerCase",
        "ELEMENT","trim","replace","DOCUMENT","size","byteLength","toString",
        "stack","substr","message","indexOf","key","type","unserializableValue",
        "objectId","className","preview","proxyTargetValue","customPreview",
        "CustomPreview","resolve","then","console","error","header","hasBody",
        "stringify","ObjectPreview","ObjectPreviewType","properties",
        "ObjectPreviewSubtype","getInternalProperties","wasThrown","indexes",
        "overflow","valuePreview","entries"];
    scriptSource += `(function installSettersAndGetters() {
        let defineProperty = Object.defineProperty;
        let ObjectPrototype = Object.prototype;
        let ArrayPrototype = Array.prototype;
        defineProperty(ArrayPrototype, 0, {
          set() { debugger; throw 42; }, get() { debugger; throw 42; },
          __proto__: null
        });`,
        scriptSource += getters.map(getter => `
        defineProperty(ObjectPrototype, '${getter}', {
          set() { debugger; throw 42; }, get() { debugger; throw 42; },
          __proto__: null
        });
        `).join('\n') + '})();';
    this.addScript(scriptSource);

    if (session) {
      InspectorTest.log('WARNING: setupInjectedScriptEnvironment with debug flag for debugging only and should not be landed.');
      session.setupScriptMap();
      session.Protocol.Debugger.enable();
      session.Protocol.Debugger.onPaused(message => {
        let callFrames = message.params.callFrames;
        session.logSourceLocations(callFrames.map(frame => frame.location));
      })
    }
  }
};

InspectorTest.Session = class {
  constructor(contextGroup, isFullyTrusted) {
    this.contextGroup = contextGroup;
    this._dispatchTable = new Map();
    this._eventHandlers = new Map();
    this._requestId = 0;
    this._isFullyTrusted = isFullyTrusted;
    this.Protocol = this._setupProtocol();
    InspectorTest._sessions.add(this);
    this.id = utils.connectSession(contextGroup.id, '', this._dispatchMessage.bind(this), isFullyTrusted);
  }

  disconnect() {
    InspectorTest._sessions.delete(this);
    utils.disconnectSession(this.id);
  }

  reconnect() {
    var state = utils.disconnectSession(this.id);
    this.id = utils.connectSession(this.contextGroup.id, state, this._dispatchMessage.bind(this), this._isFullyTrusted);
  }

  async addInspectedObject(serializable) {
    return this.Protocol.Runtime.evaluate({expression: `inspector.addInspectedObject(${this.id}, ${JSON.stringify(serializable)})`});
  }

  sendRawCommand(requestId, command, handler) {
    if (InspectorTest._dumpInspectorProtocolMessages)
      utils.print("frontend: " + command);
    this._dispatchTable.set(requestId, handler);
    utils.sendMessageToBackend(this.id, command);
  }

  stop() {
    utils.stop(this.id);
  }

  setupScriptMap() {
    if (this._scriptMap)
      return;
    this._scriptMap = new Map();
  }

  getCallFrameUrl(frame) {
    const {scriptId} = frame.location ? frame.location : frame;
    return (this._scriptMap.get(scriptId) ?? frame).url;
  }

  logCallFrames(callFrames) {
    for (var frame of callFrames) {
      var functionName = frame.functionName || '(anonymous)';
      var url = this.getCallFrameUrl(frame);
      var lineNumber = frame.location ? frame.location.lineNumber : frame.lineNumber;
      var columnNumber = frame.location ? frame.location.columnNumber : frame.columnNumber;
      InspectorTest.log(`${functionName} (${url}:${lineNumber}:${columnNumber})`);
    }
  }

  async getScriptWithSource(scriptId, forceSourceRequest) {
    var script = this._scriptMap.get(scriptId);
    if (forceSourceRequest || !(script.scriptSource || script.bytecode)) {
      var message = await this.Protocol.Debugger.getScriptSource({ scriptId });
      script.scriptSource = message.result.scriptSource;
      if (message.result.bytecode) {
        script.bytecode = InspectorTest.decodeBase64(message.result.bytecode);
      }
    }
    return script;
  }

  async logSourceLocation(location, forceSourceRequest) {
    var scriptId = location.scriptId;
    if (!this._scriptMap || !this._scriptMap.has(scriptId)) {
      InspectorTest.log("setupScriptMap should be called before Protocol.Debugger.enable.");
      InspectorTest.completeTest();
    }
    var script = await this.getScriptWithSource(scriptId, forceSourceRequest);

    if (script.bytecode) {
      if (location.lineNumber != 0) {
        InspectorTest.log('Unexpected wasm line number: ' + location.lineNumber);
      }
      let wasm_opcode = script.bytecode[location.columnNumber];
      let opcode_str = wasm_opcode.toString(16);
      if (opcode_str.length % 2) opcode_str = `0${opcode_str}`;
      if (InspectorTest.getWasmOpcodeName) {
        opcode_str += ` (${InspectorTest.getWasmOpcodeName(wasm_opcode)})`;
      }
      InspectorTest.log(`Script ${script.url} byte offset ${
          location.columnNumber}: Wasm opcode 0x${opcode_str}`);
    } else {
      var lines = script.scriptSource.split('\n');
      var line = lines[location.lineNumber];
      line = line.slice(0, location.columnNumber) + '#' + (line.slice(location.columnNumber) || '');
      lines[location.lineNumber] = line;
      lines = lines.filter(line => line.indexOf('//# sourceURL=') === -1);
      InspectorTest.log(lines.slice(Math.max(location.lineNumber - 1, 0), location.lineNumber + 2).join('\n'));
      InspectorTest.log('');
    }
  }

  logSourceLocations(locations) {
    if (locations.length == 0) return Promise.resolve();
    return this.logSourceLocation(locations[0]).then(() => this.logSourceLocations(locations.splice(1)));
  }

  async logBreakLocations(inputLocations) {
    let locations = inputLocations.slice();
    let scriptId = locations[0].scriptId;
    let script = await this.getScriptWithSource(scriptId);
    let lines = script.scriptSource.split('\n');
    locations = locations.sort((loc1, loc2) => {
      if (loc2.lineNumber !== loc1.lineNumber) return loc2.lineNumber - loc1.lineNumber;
      return loc2.columnNumber - loc1.columnNumber;
    });
    for (let location of locations) {
      let line = lines[location.lineNumber];
      line = line.slice(0, location.columnNumber) + locationMark(location.type) + line.slice(location.columnNumber);
      lines[location.lineNumber] = line;
    }
    lines = lines.filter(line => line.indexOf('//# sourceURL=') === -1);
    InspectorTest.log(lines.join('\n') + '\n');
    return inputLocations;

    function locationMark(type) {
      if (type === 'return') return '|R|';
      if (type === 'call') return '|C|';
      if (type === 'debuggerStatement') return '|D|';
      return '|_|';
    }
  }

  async logTypeProfile(typeProfile, source) {
    let entries = typeProfile.entries;

    // Sort in reverse order so we can replace entries without invalidating
    // the other offsets.
    entries = entries.sort((a, b) => b.offset - a.offset);

    for (let entry of entries) {
      source = source.slice(0, entry.offset) + typeAnnotation(entry.types) +
        source.slice(entry.offset);
    }
    InspectorTest.log(source);
    return typeProfile;

    function typeAnnotation(types) {
      return `/*${types.map(t => t.name).join(', ')}*/`;
    }
  }

  logAsyncStackTrace(asyncStackTrace) {
    while (asyncStackTrace) {
      InspectorTest.log(`-- ${asyncStackTrace.description || '<empty>'} --`);
      this.logCallFrames(asyncStackTrace.callFrames);
      if (asyncStackTrace.parentId) InspectorTest.log('  <external stack>');
      asyncStackTrace = asyncStackTrace.parent;
    }
  }

  _sendCommandPromise(method, params) {
    if (typeof params !== 'object')
      utils.print(`WARNING: non-object params passed to invocation of method ${method}`);
    if (InspectorTest._commandsForLogging.has(method))
      utils.print(method + ' called');
    var requestId = ++this._requestId;
    var messageObject = { "id": requestId, "method": method, "params": params };
    return new Promise(fulfill => this.sendRawCommand(requestId, JSON.stringify(messageObject), fulfill));
  }

  _setupProtocol() {
    return new Proxy({}, { get: (target, agentName, receiver) => new Proxy({}, {
      get: (target, methodName, receiver) => {
        const eventPattern = /^on(ce)?([A-Z][A-Za-z0-9]+)/;
        var match = eventPattern.exec(methodName);
        if (!match)
          return args => this._sendCommandPromise(`${agentName}.${methodName}`, args || {});
        var eventName = match[2];
        eventName = eventName.charAt(0).toLowerCase() + eventName.slice(1);
        if (match[1])
          return numOfEvents => this._waitForEventPromise(
                     `${agentName}.${eventName}`, numOfEvents || 1);
        return listener => this._eventHandlers.set(`${agentName}.${eventName}`, listener);
      }
    })});
  }

  _dispatchMessage(messageString) {
    var messageObject = JSON.parse(messageString);
    if (InspectorTest._dumpInspectorProtocolMessages)
      utils.print("backend: " + JSON.stringify(messageObject));
    const kMethodNotFound = -32601;
    if (messageObject.error && messageObject.error.code === kMethodNotFound) {
      InspectorTest.log(`Error: Called non-existent method. ${
          messageObject.error.message} code: ${messageObject.error.code}`);
      InspectorTest.completeTest();
    }
    try {
      var messageId = messageObject["id"];
      if (typeof messageId === "number") {
        var handler = this._dispatchTable.get(messageId);
        if (handler) {
          handler(messageObject);
          this._dispatchTable.delete(messageId);
        }
      } else {
        var eventName = messageObject["method"];
        var eventHandler = this._eventHandlers.get(eventName);
        if (this._scriptMap && eventName === "Debugger.scriptParsed")
          this._scriptMap.set(messageObject.params.scriptId, JSON.parse(JSON.stringify(messageObject.params)));
        if (eventName === "Debugger.scriptParsed" && messageObject.params.url === "wait-for-pending-tasks.js")
          return;
        if (eventHandler)
          eventHandler(messageObject);
      }
    } catch (e) {
      InspectorTest.log("Exception when dispatching message: " + e + "\n" + e.stack + "\n message = " + JSON.stringify(messageObject, null, 2));
      InspectorTest.completeTest();
    }
  };

  _waitForEventPromise(eventName, numOfEvents) {
    let events = [];
    return new Promise(fulfill => {
      this._eventHandlers.set(eventName, result => {
        --numOfEvents;
        events.push(result);
        if (numOfEvents === 0) {
          delete this._eventHandlers.delete(eventName);
          fulfill(events.length > 1 ? events : events[0]);
        }
      });
    });
  }
};

InspectorTest.runTestSuite = function(testSuite) {
  function nextTest() {
    if (!testSuite.length) {
      InspectorTest.completeTest();
      return;
    }
    var fun = testSuite.shift();
    InspectorTest.log("\nRunning test: " + fun.name);
    fun(nextTest);
  }
  nextTest();
}

InspectorTest.runAsyncTestSuite = async function(testSuite) {
  const selected = testSuite.filter(test => test.name.startsWith('f_'));
  if (selected.length)
    testSuite = selected;
  for (var test of testSuite) {
    InspectorTest.log("\nRunning test: " + test.name);
    try {
      await test();
    } catch (e) {
      utils.print(e.stack || "Caught error without stack trace!");
    }
  }
  InspectorTest.completeTest();
}

InspectorTest.start = function(description) {
  try {
    InspectorTest.log(description);
    var contextGroup = new InspectorTest.ContextGroup();
    var session = contextGroup.connect();
    return { session: session, contextGroup: contextGroup, Protocol: session.Protocol };
  } catch (e) {
    utils.print(e.stack);
  }
}

/**
 * Two helper functions for the tests in `debugger/restart-frame/*`.
 */

InspectorTest.evaluateAndWaitForPause = async (expression) => {
  const pausedPromise = Protocol.Debugger.oncePaused();
  const evaluatePromise = Protocol.Runtime.evaluate({ expression });

  const { params: { callFrames } } = await pausedPromise;
  InspectorTest.log('Paused at (after evaluation):');
  await session.logSourceLocation(callFrames[0].location);

  // Ignore the last frame, it's always an anonymous empty frame for the
  // Runtime#evaluate call.
  InspectorTest.log('Pause stack:');
  for (const frame of callFrames.slice(0, -1)) {
    InspectorTest.log(`  ${frame.functionName}:${frame.location.lineNumber} (canBeRestarted = ${frame.canBeRestarted ?? false})`);
  }
  InspectorTest.log('');

  return { callFrames, evaluatePromise };
};

// TODO(crbug.com/1303521): Remove `quitOnFailure` once no longer needed.
InspectorTest.restartFrameAndWaitForPause = async (callFrames, index, quitOnFailure = true) => {
  const pausedPromise = Protocol.Debugger.oncePaused();
  const frame = callFrames[index];

  InspectorTest.log(`Restarting function "${frame.functionName}" ...`);
  const response = await Protocol.Debugger.restartFrame({ callFrameId: frame.callFrameId, mode: 'StepInto' });
  if (response.error) {
    InspectorTest.log(`Failed to restart function "${frame.functionName}":`);
    InspectorTest.logMessage(response.error);
    if (quitOnFailure) {
      InspectorTest.completeTest();
    }
    return;
  }

  const { params: { callFrames: pausedCallFrames } } = await pausedPromise;
  InspectorTest.log('Paused at (after restart):');
  await session.logSourceLocation(pausedCallFrames[0].location);

  return callFrames;
};
